/**
 * @license
 * Copyright 2021 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * Array of pseudo classes to transform by default. These pseudo classes
 * represent state interactions from the user (such as :hover) or the browser
 * (such as :autofill) that cannot be reproduced with HTML markup.
 */
export const defaultTransformPseudoClasses = [
  ':active',
  ':autofill',
  ':focus',
  ':focus-visible',
  ':focus-within',
  ':hover',
  ':invalid',
  ':link',
  ':paused',
  ':playing',
  ':user-invalid',
  ':valid',
  ':visited',
];

/**
 * Retrieves the transformed class name for a given pseudo class.
 *
 * @param pseudoClass The pseudo class to transform.
 * @return The transform pseudo class string.
 */
export function getTransformedPseudoClass(pseudoClass: string) {
  return `_${pseudoClass.substring(1)}`;
}

/**
 * A weak set of stylesheets to use as reference for whether or not a stylesheet
 * has been transformed.
 */
const transformedStyleSheets = new WeakSet<CSSStyleSheet>();

/**
 * Transforms a document's stylesheets' pseudo classes into normal classes with
 * a new stylesheet.
 *
 * Pseudo classes are given an underscore in their transformation. For example,
 * `:hover` transforms to `._hover`.
 *
 * ```css
 * .mdc-foo:hover {
 *   color: teal;
 * }
 * ```
 * ```css
 * .mdc-foo._hover {
 *   color: teal;
 * }
 * ```
 *
 * @param pseudoClasses An optional array of pseudo class names to transform.
 */
export function transformPseudoClasses(
  stylesheets: Iterable<CSSStyleSheet>,
  pseudoClasses = defaultTransformPseudoClasses,
) {
  for (const stylesheet of stylesheets) {
    if (transformedStyleSheets.has(stylesheet)) {
      continue;
    }

    let rules: CSSRuleList;
    try {
      rules = stylesheet.cssRules;
    } catch {
      continue;
    }

    for (let j = rules.length - 1; j >= 0; j--) {
      visitRule(rules[j], stylesheet, j, pseudoClasses);
    }

    transformedStyleSheets.add(stylesheet);
  }
}

/**
 * Determines whether or not the CSSRule is a CSSGroupingRule.
 *
 * Cannot check instanceof because FF treats a CSSStyleRule as a subclass of
 * CSSGroupingRule unlike Chrome and Safari
 */
function isCSSGroupingRule(rule: CSSRule): rule is CSSGroupingRule {
  return (
    !!(rule as CSSGroupingRule)?.cssRules &&
    !(rule as CSSStyleRule).selectorText
  );
}

/**
 * Visits a rule for the given stylesheet and adds a rule that replaces any
 * pseudo classes with a regular transformed class for simulation styling.
 *
 * @param rule The CSS rule to transform.
 * @param stylesheet The rule's parent stylesheet to update.
 * @param index The index of the rule in the parent stylesheet.
 * @param pseudoClasses An array of pseudo classes to search for and replace.
 */
function visitRule(
  rule: CSSRule,
  stylesheet: CSSStyleSheet | CSSGroupingRule,
  index: number,
  pseudoClasses: string[],
) {
  if (isCSSGroupingRule(rule)) {
    for (let i = rule.cssRules.length - 1; i >= 0; i--) {
      visitRule(rule.cssRules[i], rule, i, pseudoClasses);
    }
    return;
  }

  if (!(rule instanceof CSSStyleRule)) {
    return;
  }

  try {
    let {selectorText} = rule;
    // match :foo, ensuring that it does not have a paren at the end
    // (no pseudo class functions like :foo())
    const regex = /(:(?![\w-]+\()[\w-]+)/g;
    const matches = Array.from(selectorText.matchAll(regex)).filter((match) => {
      // don't match pseudo elements like ::foo
      if (match.index != null && selectorText[match.index - 1] === ':') {
        return false;
      }
      return pseudoClasses.includes(match[1]);
    });

    if (!matches.length) {
      return;
    }

    matches.reverse();
    selectorText = rearrangePseudoElements(selectorText);
    for (const match of matches) {
      selectorText =
        selectorText.substring(0, match.index!) +
        `.${getTransformedPseudoClass(match[1])}` +
        selectorText.substring(match.index! + match[1].length);
    }

    const css = `${selectorText} {${rule.style.cssText}}`;
    stylesheet.insertRule(css, index + 1);
  } catch (error: unknown) {
    // Catch exception to skip the rule that cannot be parsed.
    console.error(error);
  }
}

/**
 * Re-arranges a selector's pseudo elements to appear at the end of the
 * selector. This prevents invalid CSS when replacing pseudo classes that
 * appear after a pseudo element.
 *
 * @example
 * // '.foo::before:hover' -> '.foo::before._hover' is invalid
 *
 * rearrangePseudoElements('.foo::before:hover'); // '.foo:hover::before'
 * // '.foo:hover::before' -> '.foo._hover::before' is valid
 *
 * @param selectorText The selector text string to re-arrange.
 * @return The re-arranged selector text.
 */
function rearrangePseudoElements(selectorText: string) {
  const pseudoElementsBeforeClasses = Array.from(
    selectorText.matchAll(/(?:::[\w-]+)+(?=:[\w-])/g),
  );
  pseudoElementsBeforeClasses.reverse();
  for (const match of pseudoElementsBeforeClasses) {
    const pseudoElement = match[0];
    const pseudoElementIndex = match.index!;
    const endOfCompoundSelector = selectorText
      .substring(pseudoElementIndex)
      .match(/(\s(?!([^\s].)*\))|,|$)/)!;
    const index = endOfCompoundSelector.index! + pseudoElementIndex;
    selectorText =
      selectorText.substring(0, index) +
      pseudoElement +
      selectorText.substring(index);
    selectorText =
      selectorText.substring(0, pseudoElementIndex) +
      selectorText.substring(pseudoElementIndex + pseudoElement.length);
  }

  return selectorText;
}
